Roberto Tomé

ROBERTO TOMÉ

Building a To-Do List Web App with React and Zustand: A Complete Tutorial
Tutorials

Building a To-Do List Web App with React and Zustand: A Complete Tutorial

Building a To-Do List Web App with React and Zustand: A Complete Tutorial

Overview

This tutorial will guide you through building a modern To-Do List application using React and Zustand for state management. By the end, you’ll have a fully functional app with add, toggle, delete, and persist functionality.
 

Why Choose Zustand?

Zustand is a lightweight, fast, and scalable state management library that offers several advantages over traditional solutions like Redux:

  • Minimal boilerplate: No reducers, actions, or providers needed
  • Simple API: Easy to learn and implement
  • Performance-focused: Components only re-render when subscribed state changes
  • TypeScript-ready: Excellent TypeScript support out of the box
  • Tiny bundle size: Only 1.1kB minified + gzipped
     

Prerequisites

  • Basic knowledge of React (components, hooks, JSX)
  • Understanding of useContext (helpful but not required)
  • Node.js installed on your system
  • Basic JavaScript/TypeScript knowledge
     

Project Setup

Step 1: Create the React Application

We’ll use Vite instead of Create React App, as CRA has been deprecated on February 2025:

npm create vite@latest todo-app-zustand -- --template react-ts
cd todo-app-zustand
npm install

Step 2: Install Zustand

npm install zustand

Step 3: Install Development Tools (Optional)

For better debugging experience:

npm install --save-dev @redux-devtools/extension


 

Folder Structure

Following modern React best practices, organize your project like this:

src/
├── components/
│   ├── TodoForm/
│   │   ├── TodoForm.tsx
│   │   └── TodoForm.module.css
│   ├── TodoList/
│   │   ├── TodoList.tsx
│   │   └── TodoList.module.css
│   └── TodoItem/
│       ├── TodoItem.tsx
│       └── TodoItem.module.css
├── store/
│   └── todoStore.ts
├── types/
│   └── todo.ts
├── hooks/
│   └── useTodoStore.ts
├── App.tsx
├── App.css
└── main.tsx


 

Step-by-Step Implementation

Step 1: Define Types

Create src/types/todo.ts:

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
}

export interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
  clearCompleted: () => void;
}

Step 2: Create the Zustand Store

Create src/store/todoStore.ts:

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Todo, TodoStore } from '../types/todo';

export const useTodoStore = create<TodoStore>()(
  devtools(
    persist(
      (set, get) => ({
        todos: [],
        
        addTodo: (text: string) => {
          if (text.trim().length === 0) return;
          
          const newTodo: Todo = {
            id: crypto.randomUUID(),
            text: text.trim(),
            completed: false,
            createdAt: new Date(),
          };
          
          set(
            (state) => ({
              todos: [...state.todos, newTodo],
            }),
            false,
            'addTodo'
          );
        },
        
        toggleTodo: (id: string) => {
          set(
            (state) => ({
              todos: state.todos.map((todo) =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
              ),
            }),
            false,
            'toggleTodo'
          );
        },
        
        removeTodo: (id: string) => {
          set(
            (state) => ({
              todos: state.todos.filter((todo) => todo.id !== id),
            }),
            false,
            'removeTodo'
          );
        },
        
        clearCompleted: () => {
          set(
            (state) => ({
              todos: state.todos.filter((todo) => !todo.completed),
            }),
            false,
            'clearCompleted'
          );
        },
      }),
      {
        name: 'todo-storage',
      }
    ),
    {
      name: 'todo-store',
    }
  )
);

Key Features Explained:

  • devtools: Enables Redux DevTools for debugging.
  • persist: Automatically saves state to localStorage.
  • Atomic actions: Each action has a specific name for debugging.
  • Immutable updates: Using spread operators to avoid direct mutation.
     

Step 3: Create Custom Hook (Best Practice)

Create src/hooks/useTodoStore.ts:

import { useTodoStore as useStore } from '../store/todoStore';

// Only export custom hooks, not the store directly
export const useTodos = () => useStore((state) => state.todos);
export const useAddTodo = () => useStore((state) => state.addTodo);
export const useToggleTodo = () => useStore((state) => state.toggleTodo);
export const useRemoveTodo = () => useStore((state) => state.removeTodo);
export const useClearCompleted = () => useStore((state) => state.clearCompleted);

// Computed values
export const useCompletedCount = () => 
  useStore((state) => state.todos.filter(todo => todo.completed).length);

export const useActiveCount = () => 
  useStore((state) => state.todos.filter(todo => !todo.completed).length);

Why This Approach?

  • Performance: Only subscribes to specific parts of state.
  • Maintainability: Easier to refactor and test.
  • Encapsulation: Hides store implementation details.
     

Step 4: Create TodoForm Component

Create src/components/TodoForm/TodoForm.tsx:

import React, { useState } from 'react';
import { useAddTodo } from '../../hooks/useTodoStore';
import styles from './TodoForm.module.css';

export const TodoForm: React.FC = () => {
  const [inputValue, setInputValue] = useState('');
  const addTodo = useAddTodo();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    if (inputValue.trim()) {
      addTodo(inputValue);
      setInputValue('');
    }
  };

  return (
    <form onSubmit={handleSubmit} className={styles.form}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="What needs to be done?"
        className={styles.input}
        autoFocus
      />
      <button type="submit" className={styles.button}>
        Add Todo
      </button>
    </form>
  );
};

Step 5: Create TodoItem Component

Create src/components/TodoItem/TodoItem.tsx:

import React from 'react';
import { Todo } from '../../types/todo';
import { useToggleTodo, useRemoveTodo } from '../../hooks/useTodoStore';
import styles from './TodoItem.module.css';

interface TodoItemProps {
  todo: Todo;
}

export const TodoItem: React.FC<TodoItemProps> = ({ todo }) => {
  const toggleTodo = useToggleTodo();
  const removeTodo = useRemoveTodo();

  return (
    <li className={`${styles.item} ${todo.completed ? styles.completed : ''}`}>
      <label className={styles.label}>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => toggleTodo(todo.id)}
          className={styles.checkbox}
        />
        <span className={styles.text}>{todo.text}</span>
      </label>
      <button
        onClick={() => removeTodo(todo.id)}
        className={styles.removeButton}
        aria-label={`Remove ${todo.text}`}
      >
        ×
      </button>
    </li>
  );
};

Step 6: Create TodoList Component

Create src/components/TodoList/TodoList.tsx:

import React from 'react';
import { TodoItem } from '../TodoItem/TodoItem';
import { useTodos, useClearCompleted, useCompletedCount } from '../../hooks/useTodoStore';
import styles from './TodoList.module.css';

export const TodoList: React.FC = () => {
  const todos = useTodos();
  const clearCompleted = useClearCompleted();
  const completedCount = useCompletedCount();

  if (todos.length === 0) {
    return (
      <div className={styles.emptyState}>
        <p>No todos yet. Add your first task above!</p>
      </div>
    );
  }

  return (
    <div className={styles.container}>
      <ul className={styles.list}>
        {todos.map((todo) => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
      
      {completedCount > 0 && (
        <div className={styles.actions}>
          <button onClick={clearCompleted} className={styles.clearButton}>
            Clear {completedCount} completed {completedCount === 1 ? 'task' : 'tasks'}
          </button>
        </div>
      )}
    </div>
  );
};

Step 7: Create Main App Component

Update src/App.tsx:

import React from 'react';
import { TodoForm } from './components/TodoForm/TodoForm';
import { TodoList } from './components/TodoList/TodoList';
import { useActiveCount, useCompletedCount } from './hooks/useTodoStore';
import './App.css';

function App() {
  const activeCount = useActiveCount();
  const completedCount = useCompletedCount();

  return (
    <div className="app">
      <header className="app-header">
        <h1>Todo App with Zustand</h1>
        <div className="stats">
          <span>{activeCount} active</span>
          <span>{completedCount} completed</span>
        </div>
      </header>
      
      <main className="app-main">
        <TodoForm />
        <TodoList />
      </main>
    </div>
  );
}

export default App;


 

Styling (Optional)

Add basic CSS in src/App.css:

.app {
    min-height: 100vh;
    min-width: 100vw;
    background-color: #f8f9fa;
    padding: 2rem;
    font-family: Arial, sans-serif;
    display: flex;  
    flex-direction: column;
    align-items: center;
    justify-content: center;     
    box-sizing: border-box;                                                                              
}

.app-header {
  text-align: center;
  margin-bottom: 2rem;
}

.app-header h1 {
  margin: 0 0 1rem 0;
  color: #333;
}

.stats {
  display: flex;
  gap: 1rem;
  justify-content: center;
  color: #666;
  font-size: 0.9rem;
}

.app-main {
  display: flex;
  flex-direction: column
}


 

Common Pitfalls and How to Avoid Them

1. Direct State Mutation

Wrong:

// DON'T do this
addTodo: (text: string) => {
  const todos = get().todos;
  todos.push(newTodo); // Direct mutation!
  set({ todos });
}

Correct:

// DO this instead
addTodo: (text: string) => {
  set((state) => ({
    todos: [...state.todos, newTodo] // Create new array
  }));
}

2. Overusing Global State

Wrong:

// Don't put everything in global state
interface TodoStore {
  todos: Todo[];
  inputValue: string; // This should be local!
  isFormValid: boolean; // This too!
}

Correct:

// Keep form state local
const TodoForm = () => {
  const [inputValue, setInputValue] = useState(''); // Local state
  const addTodo = useAddTodo(); // Global action
}

3. Not Using Selectors Properly

Wrong:

// This causes unnecessary re-renders
const { todos, addTodo, toggleTodo } = useTodoStore();

Correct:

// Only subscribe to what you need
const todos = useTodoStore(state => state.todos);
const addTodo = useTodoStore(state => state.addTodo);

4. Missing Error Boundaries

Always wrap your app with error boundaries to catch and handle errors gracefully.

5. Forgetting to Handle Loading States

For async operations, always handle loading and error states properly.
 

Best Practices Summary

  1. Only export custom hooks, not the store directly
  2. Use atomic selectors for better performance
  3. Keep actions simple and focused on one responsibility
  4. Name your actions for better debugging with DevTools
  5. Use TypeScript for better type safety and developer experience
  6. Persist important state using the persist middleware
  7. Keep global state minimal - use local state when possible
     

Debugging with Redux DevTools

To use Redux DevTools with your Zustand store:

  1. Install the Redux DevTools browser extension
  2. The devtools middleware is already configured in our store
  3. Open browser DevTools → Redux tab
  4. You’ll see all state changes with action names
     

Running the Application

npm run dev

Your todo app will be available at http://localhost:5173 with full functionality:

  • ✅ Add new todos
  • ✅ Toggle completion status
  • ✅ Delete individual todos
  • ✅ Clear all completed todos
  • ✅ Persist data in localStorage
  • ✅ Debug with Redux DevTools
     

Extending the App

Consider adding these features to practice:

  • Filtering (All, Active, Completed)
  • Editing todos inline
  • Due dates and priorities
  • Categories or tags
  • Search functionality
  • Drag and drop reordering

This tutorial provides a solid foundation for building React applications with Zustand. The patterns shown here scale well for larger applications while maintaining simplicity and performance.

Tutorial code available at: https://github.com/rtome85/todo-app-zustand

Tags:

Software Development React Zustand

Share this post:

Building a To-Do List Web App with React and Zustand: A Complete Tutorial